//
//  SwiftFormat.swift
//  SwiftFormat
//
//  Created by Nick Lockwood on 12/08/2016.
//  Copyright 2016 Nick Lockwood
//
//  Distributed under the permissive MIT license
//  Get the latest version from here:
//
//  https://github.com/nicklockwood/SwiftFormat
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in all
//  copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
//  SOFTWARE.
//

import Foundation

/// The current SwiftFormat version
let swiftFormatVersion = "0.58.6"
public let version = swiftFormatVersion

/// The standard SwiftFormat config file name
public let swiftFormatConfigurationFile = ".swiftformat"

/// The standard Swift version file name
public let swiftVersionFile = ".swift-version"

/// Supported Swift compiler versions
public let swiftVersions = [
    "3.x", "4.0", "4.1", "4.2",
    "5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10",
    "6.0", "6.1", "6.2", "6.3",
]

/// Supported Swift language modes
public let languageModes = [
    "4", "4.2", "5", "6",
]

/// The default language mode for the given Swift compiler version
func defaultLanguageMode(for compilerVersion: Version) -> Version {
    switch compilerVersion {
    case "4.0" ..< "4.2":
        return "4"
    case "4.2":
        return "4.2"
    case "5.0" ..< "6.0":
        return "5"
    case "6.0"...:
        // The default language mode in Swift 6.0 is Swift 5 mode.
        // https://developer.apple.com/documentation/swift/adoptingswift6
        return "5"
    default:
        return .undefined
    }
}

/// An enumeration of the types of error that may be thrown by SwiftFormat
public enum FormatError: Error, CustomStringConvertible, LocalizedError, CustomNSError {
    case reading(String)
    case writing(String)
    case parsing(String)
    case options(String)

    static func invalidOption(
        _ option: String,
        for argumentName: String,
        with validOptions: [String]
    ) -> Self {
        let message = "Unsupported --\(argumentName) value '\(option)'"
        guard let match = option.bestMatch(in: validOptions) else {
            return .options("\(message). Valid options are \(validOptions.formattedList())")
        }
        return .options("\(message). Did you mean '\(match)'?")
    }

    public var description: String {
        switch self {
        case let .reading(string),
             let .writing(string),
             let .parsing(string),
             let .options(string):
            return string
        }
    }

    public var localizedDescription: String {
        "Error: \(description)."
    }

    public var errorUserInfo: [String: Any] {
        [NSLocalizedDescriptionKey: localizedDescription]
    }
}

/// Callback for enumerateFiles() function
public typealias FileEnumerationHandler = (
    _ inputURL: URL,
    _ ouputURL: URL,
    _ options: Options
) throws -> () throws -> Void

/// Callback for info-level logging
public typealias Logger = (String) -> Void

/// Enumerate all Swift files at the specified location and (optionally) calculate an output file URL for each.
/// Ignores the file if any of the excluded file URLs is a prefix of the input file URL.
///
/// Files are enumerated concurrently. For convenience, the enumeration block returns a completion block, which
/// will be executed synchronously on the calling thread once enumeration is complete.
///
/// Errors may be thrown by either the enumeration block or the completion block, and are gathered into an
/// array and returned after enumeration is complete, along with any errors generated by the function itself.
/// Throwing an error from inside either block does *not* terminate the enumeration.
public func enumerateFiles(withInputURLs inputURLs: [URL],
                           outputURL: URL? = nil,
                           options baseOptions: Options = .default,
                           concurrent: Bool = true,
                           logger: Logger? = nil,
                           skipped: FileEnumerationHandler? = nil,
                           handler: @escaping FileEnumerationHandler) -> [Error]
{
    let manager = FileManager.default
    let keys: [URLResourceKey] = [
        .isRegularFileKey, .isDirectoryKey,
        .isAliasFileKey, .isSymbolicLinkKey,
        .creationDateKey, .pathKey,
    ]

    let group = DispatchGroup()
    var completionBlocks = [() throws -> Void]()
    let completionQueue = DispatchQueue(label: "swiftformat.enumeration")
    func onComplete(_ block: @escaping () throws -> Void) {
        completionQueue.async(group: group) {
            completionBlocks.append(block)
        }
    }

    let queue = concurrent ? DispatchQueue.global(qos: .userInitiated) : completionQueue

    func resolveInputURL(_ inputURL: URL, options: Options) -> (URL, URLResourceValues, Options)? {
        let fileOptions = options.fileOptions ?? .default
        let inputURL = inputURL.standardizedFileURL
        if options.shouldSkipFile(inputURL) {
            if let handler = skipped {
                do {
                    try onComplete(handler(inputURL, inputURL, options))
                } catch {
                    onComplete { throw error }
                }
            }
            return nil
        }
        do {
            let resourceValues = try getResourceValues(for: inputURL, keys: keys)
            #if os(macOS)
                if resourceValues.isAliasFile == true {
                    if fileOptions.followSymlinks {
                        guard let resolvedURL = try? URL(resolvingAliasFileAt: inputURL) else {
                            throw FormatError.options("Could not resolve alias at \(inputURL.path)")
                        }
                        return resolveInputURL(resolvedURL, options: options)
                    } else {
                        if let handler = skipped {
                            try onComplete(handler(inputURL, inputURL, options))
                        }
                        return nil
                    }
                }
            #endif
            if resourceValues.isSymbolicLink == true {
                if fileOptions.followSymlinks {
                    let resolvedURL = inputURL.resolvingSymlinksInPath()
                    return resolveInputURL(resolvedURL, options: options)
                } else {
                    if let handler = skipped {
                        try onComplete(handler(inputURL, inputURL, options))
                    }
                    return nil
                }
            } else {
                return (inputURL, resourceValues, options)
            }
        } catch {
            onComplete { throw error }
            return nil
        }
    }

    func enumerate(inputURL: URL,
                   outputURL: URL?,
                   options: Options)
    {
        assert(options.formatOptions != nil)
        guard let (inputURL, resourceValues, options) = resolveInputURL(inputURL, options: options) else {
            return
        }
        assert(options.formatOptions != nil)
        let fileOptions = options.fileOptions ?? .default
        if resourceValues.isRegularFile == true {
            if fileOptions.supportedFileExtensions.contains(inputURL.pathExtension) {
                let fileInfo = collectFileInfo(inputURL: inputURL,
                                               options: options,
                                               resourceValues: resourceValues)

                var options = options
                options.formatOptions?.fileInfo = fileInfo
                do {
                    try onComplete(handler(inputURL, outputURL ?? inputURL, options))
                } catch {
                    onComplete { throw error }
                }
            }
        } else if resourceValues.isDirectory == true {
            var tempOptions = options
            do {
                try processDirectory(inputURL, with: &tempOptions, logger: logger)
            } catch {
                onComplete { throw error }
                return
            }
            let options = tempOptions
            let enumerationOptions: FileManager.DirectoryEnumerationOptions = .skipsHiddenFiles
            guard let files = try? manager.contentsOfDirectory(
                at: inputURL, includingPropertiesForKeys: keys, options: enumerationOptions
            ) else {
                onComplete { throw FormatError.reading("Failed to read contents of directory at \(inputURL.path)") }
                return
            }
            for url in files where !url.path.hasPrefix(".") {
                let options = options
                queue.async(group: group) {
                    let outputURL = outputURL.map {
                        URL(fileURLWithPath: $0.path + url.path[inputURL.path.endIndex ..< url.path.endIndex])
                    }
                    enumerate(inputURL: url, outputURL: outputURL, options: options)
                }
            }
        }
    }

    for inputURL in inputURLs {
        queue.async(group: group) {
            var options = baseOptions
            var inputURL = inputURL
            if options.formatOptions == nil {
                options.formatOptions = .default
            }
            do {
                try gatherOptions(&options, for: inputURL, with: logger)
                guard let (resolvedURL, resourceValues, _) = resolveInputURL(inputURL, options: options) else {
                    return
                }
                inputURL = resolvedURL
                let fileOptions = options.fileOptions ?? .default
                if resourceValues.isDirectory == false,
                   !fileOptions.supportedFileExtensions.contains(inputURL.pathExtension)
                {
                    throw FormatError.options("Unsupported file type: \(inputURL.path)")
                }
            } catch {
                onComplete { throw error }
                return
            }
            enumerate(inputURL: inputURL, outputURL: outputURL, options: options)
        }
    }
    group.wait()

    var errors = [Error]()
    for block in completionBlocks {
        do {
            try block()
        } catch {
            errors.append(error)
        }
    }
    return errors
}

@available(*, deprecated)
public func enumerateFiles(withInputURL inputURL: URL,
                           outputURL: URL? = nil,
                           options baseOptions: Options = .default,
                           concurrent: Bool = true,
                           logger: Logger? = nil,
                           skipped: FileEnumerationHandler? = nil,
                           handler: @escaping FileEnumerationHandler) -> [Error]
{
    enumerateFiles(withInputURLs: [inputURL],
                   outputURL: outputURL,
                   options: baseOptions,
                   concurrent: concurrent,
                   logger: logger,
                   skipped: skipped,
                   handler: handler)
}

func collectFileInfo(inputURL: URL, options: Options, resourceValues: URLResourceValues) -> FileInfo {
    let fileHeaderRuleEnabled = options.rules?.contains(FormatRule.fileHeader.name) ?? false
    let shouldGetGitInfo = fileHeaderRuleEnabled &&
        options.formatOptions?.fileHeader.needsGitInfo == true

    let gitInfo = shouldGetGitInfo ? GitFileInfo(url: inputURL) : nil

    return FileInfo(
        filePath: resourceValues.path,
        creationDate: gitInfo?.creationDate ?? resourceValues.creationDate,
        replacements: [
            .author: ReplacementType(gitInfo?.author),
            .authorName: ReplacementType(gitInfo?.authorName),
            .authorEmail: ReplacementType(gitInfo?.authorEmail),
        ].compactMapValues { $0 }
    )
}

/// Process configuration in all directories in specified path.
func gatherOptions(_ options: inout Options, for inputURL: URL, with logger: Logger?) throws {
    var directory = URL(fileURLWithPath: inputURL.pathComponents[0]).standardized
    for part in inputURL.pathComponents.dropFirst().dropLast() {
        directory.appendPathComponent(part)
        if options.shouldSkipFile(directory) {
            return
        }
        try processDirectory(directory, with: &options, logger: logger)
    }
}

/// Process configuration files in specified directory.
private var configCache = [URL: [[String: String]]]()
private let configQueue = DispatchQueue(label: "swiftformat.config", qos: .userInteractive)
private func processDirectory(_ inputURL: URL, with options: inout Options, logger: Logger?) throws {
    if let args = configQueue.sync(execute: { configCache[inputURL] }) {
        try options.addArguments(args, in: inputURL.path)
        return
    }
    var args = [[String: String]]()
    let manager = FileManager.default
    let configFile = inputURL.appendingPathComponent(swiftFormatConfigurationFile)
    if manager.fileExists(atPath: configFile.path) {
        if let configURLs = options.configURLs {
            let standardizedConfigFile = configFile.standardizedFileURL
            if !configURLs.contains(where: { $0.standardizedFileURL == standardizedConfigFile }) {
                logger?("Ignoring config file at \(configFile.path)")
            }
        } else {
            logger?("Reading config file at \(configFile.path)")
            let data = try Data(contentsOf: configFile)
            args = try parseConfigFile(data)
        }
    }
    let versionFile = inputURL.appendingPathComponent(swiftVersionFile)
    if manager.fileExists(atPath: versionFile.path) {
        let versionString = try String(contentsOf: versionFile, encoding: .utf8)
            .trimmingCharacters(in: .whitespacesAndNewlines)
        if args.first(where: { $0["swift-version"] != nil }) != nil {
            logger?("Ignoring swift-version file at \(versionFile.path)")
        } else if Version(rawValue: versionString) != nil {
            logger?("Reading swift-version file at \(versionFile.path) (version \(versionString))")

            if args.isEmpty {
                args = [["swift-version": versionString]]
            } else {
                args = args.map {
                    var args = $0
                    args["swift-version"] = versionString
                    return args
                }
            }
        } else {
            // Don't treat as error, per: https://github.com/nicklockwood/SwiftFormat/issues/639
            // TODO: find a better solution for logging warnings here
            logger?("Unrecognized swift version string '\(versionString)' in \(versionFile.path)")
        }
    }
    configQueue.async {
        configCache[inputURL] = args
    }
    assert(options.formatOptions != nil)
    try options.addArguments(args, in: inputURL.standardizedFileURL.path)
}

/// Line and column offset in source
/// Note: line and column indexes start at 1
public struct SourceOffset: Equatable, CustomStringConvertible {
    var line, column: Int

    public init(line: Int, column: Int) {
        self.line = line
        self.column = column
    }

    public var description: String {
        "\(line):\(column)"
    }
}

/// Get offset for token
public func offsetForToken(at index: Int, in tokens: [Token], tabWidth: Int) -> SourceOffset {
    var column = 1
    for token in tokens[..<index].reversed() {
        switch token {
        case let .linebreak(_, line):
            return SourceOffset(line: line + 1, column: column)
        default:
            column += token.columnWidth(tabWidth: tabWidth)
        }
    }
    return SourceOffset(line: 1, column: column)
}

/// Get token index for offset
public func tokenIndex(for offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> Int {
    var tokenIndex = 0, line = 1
    for index in tokens.indices {
        guard case let .linebreak(_, originalLine) = tokens[index] else {
            continue
        }
        line = originalLine
        guard originalLine < offset.line else {
            break
        }
        tokenIndex = index + 1
    }
    if line < offset.line - 1 {
        return tokens.endIndex
    }
    var column = 1
    while tokenIndex < tokens.endIndex, column < offset.column {
        column += tokens[tokenIndex].columnWidth(tabWidth: tabWidth)
        tokenIndex += 1
    }
    return tokenIndex
}

/// Deprecated
@available(*, deprecated, message: "Use tokenIndex(for:) instead")
public func tokenIndexForOffset(_ offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> Int {
    tokenIndex(for: offset, in: tokens, tabWidth: tabWidth)
}

/// Get token index range for line range
public func tokenRange(forLineRange lineRange: ClosedRange<Int>, in tokens: [Token]) -> Range<Int> {
    let startOffset = SourceOffset(line: lineRange.lowerBound, column: 0)
    let endOffset = SourceOffset(line: lineRange.upperBound + 1, column: 0)
    // NOTE: tab width is not relevant for line-based offsets
    let tokenStart = max(0, tokenIndex(for: startOffset, in: tokens, tabWidth: 1) - 1)
    let tokenEnd = max(tokenStart, tokenIndex(for: endOffset, in: tokens, tabWidth: 1) - 1)
    return tokenStart ..< tokenEnd
}

/// Get new offset for an original offset (before formatting)
public func newOffset(for offset: SourceOffset, in tokens: [Token], tabWidth: Int) -> SourceOffset {
    var closestLine = 0
    for i in tokens.indices {
        guard case let .linebreak(_, originalLine) = tokens[i] else {
            continue
        }
        closestLine += 1
        guard originalLine >= offset.line else {
            continue
        }
        var lineLength = 0
        for j in (0 ..< i).reversed() {
            let token = tokens[j]
            if token.isLinebreak {
                break
            }
            lineLength += token.columnWidth(tabWidth: tabWidth)
        }
        return SourceOffset(line: closestLine, column: min(offset.column, lineLength + 1))
    }
    let lineLength = tokens.reduce(0) { $0 + $1.columnWidth(tabWidth: tabWidth) }
    return SourceOffset(line: closestLine + 1, column: min(offset.column, lineLength + 1))
}

/// Process parsing errors
public func parsingError(for tokens: [Token], options: FormatOptions, allowErrorsInFragments: Bool = true) -> FormatError? {
    guard let index = tokens.firstIndex(where: {
        guard (options.fragment && allowErrorsInFragments) || !$0.isError else { return true }
        guard !options.ignoreConflictMarkers, case let .operator(string, _) = $0 else { return false }
        return string.hasPrefix("<<<<<") || string.hasPrefix("=====") || string.hasPrefix(">>>>>")
    }) else {
        return nil
    }
    let message: String
    switch tokens[index] {
    case .error(""):
        message = "Unexpected end of file"
    case let .error(string):
        if string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            message = "Inconsistent whitespace in multi-line string literal"
        } else {
            message = "Unexpected token \(string)"
        }
    case let .operator(string, _):
        message = "Found conflict marker \(string)"
    default:
        preconditionFailure()
    }
    let offset = offsetForToken(at: index, in: tokens, tabWidth: options.tabWidth)
    return .parsing("\(message) at \(offset)")
}

/// Convert a token array back into a string
public func sourceCode(for tokens: [Token]?) -> String {
    (tokens ?? []).map(\.string).joined()
}

/// Apply specified rules to a token array and optionally capture list of changes
public func applyRules(
    _ originalRules: [FormatRule],
    to originalTokens: [Token],
    with options: FormatOptions,
    trackChanges: Bool,
    range originalRange: Range<Int>?,
    maxIterations: Int = 10
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
    precondition(maxIterations > 1)

    let originalRules = originalRules.sorted()
    var tokens = originalTokens
    var range = originalRange

    // Ensure rule names have been set
    if originalRules.first?.name == FormatRule.unnamedRule {
        _ = FormatRules.all
    }

    // Check for parsing errors
    if let error = parsingError(for: originalTokens, options: options) {
        throw error
    }

    // Infer shared options
    var options = options
    options.enabledRules = Set(originalRules.map(\.name))
    let sharedOptions = FormatRules
        .sharedOptionsForRules(originalRules)
        .compactMap { Descriptors.byName[$0] }
        .filter { $0.defaultArgument == $0.fromOptions(options) }
        .map(\.propertyName)

    inferFormatOptions(sharedOptions, from: originalTokens, into: &options)

    // Check if required FileInfo is available
    if originalRules.contains(.fileHeader) {
        let header = options.fileHeader
        let fileInfo = options.fileInfo

        for key in ReplacementKey.allCases {
            if case let .replace(string) = header,
               !fileInfo.hasReplacement(for: key, options: options),
               string.contains(key.placeholder)
            {
                throw FormatError.options(
                    "Failed to apply \(key.placeholder) template in file header as required info is unavailable"
                )
            }
        }
    }

    // Split tokens into lines
    func getLines(in tokens: [Token], includingLinebreaks: Bool) -> [Int: ArraySlice<Token>] {
        var lines: [Int: ArraySlice<Token>] = [:]
        var startIndex = 0, nextLine = 1
        for (i, token) in tokens.enumerated() {
            if case let .linebreak(_, line) = token {
                let endIndex = i + (includingLinebreaks ? 1 : 0)
                if let existing = lines[line] {
                    lines[line] = tokens[existing.startIndex ..< endIndex]
                } else {
                    lines[line] = tokens[startIndex ..< endIndex]
                }
                nextLine = line + 1
                startIndex = i + 1
            }
        }
        lines[nextLine] = tokens[startIndex...]
        return lines
    }

    // Apply trim/indent rule once at start
    var rules = originalRules
    if rules.contains(.indent) {
        rules.insert(.indent, at: 0)
        if rules.contains(.trailingSpace) {
            rules.insert(.trailingSpace, at: 0)
        }
    }

    // Recursively apply rules until no changes are detected
    let group = DispatchGroup()
    let queue = DispatchQueue(label: "swiftformat.formatting", qos: .userInteractive)
    let timeout = options.timeout + TimeInterval(originalTokens.count) / 1000
    var changes = [Formatter.Change]()
    var lastChanges = [Formatter.Change]()
    for iteration in 0 ..< maxIterations {
        let formatter = Formatter(tokens, options: options,
                                  trackChanges: trackChanges, range: range)
        for rule in rules {
            queue.async(group: group) {
                rule.apply(with: formatter)
            }
            guard group.wait(timeout: .now() + timeout) != .timedOut else {
                throw FormatError.writing("\(rule.name) rule timed out")
            }
        }

        // Abort if there are fatal errors
        if let error = formatter.errors.first, !options.fragment {
            throw error
        }

        // Record changes
        lastChanges = formatter.changes
        changes += lastChanges

        // Update range and discard unwanted changes
        var newTokens = formatter.tokens
        if let oldRange = range, let newRange = formatter.range {
            // Discard changes outside of specified range
            newTokens = Array(tokens[..<oldRange.lowerBound] + newTokens[newRange] + tokens[oldRange.upperBound...])
            range = oldRange.lowerBound ..< (oldRange.lowerBound + newRange.count)
        }

        // Terminate early if there were no changes
        if tokens == newTokens {
            if changes.isEmpty {
                return (tokens, [])
            }

            // Sort changes
            changes.sort(by: {
                if $0.line == $1.line {
                    return $0.rule.name < $1.rule.name
                }
                return $0.line < $1.line
            })

            // Get lines
            let oldLines = getLines(in: originalTokens, includingLinebreaks: true)
            let newLines = getLines(in: tokens, includingLinebreaks: true)

            // Filter out duplicates and lines that haven't changed
            var last: Formatter.Change?
            changes = changes.filter { change in
                if last == change {
                    return false
                }
                last = change
                // Filter out lines that haven't changed from their original line in
                // the input code, unless the change was explicitly marked as a move.
                if !change.isMove, newLines[change.line] == oldLines[change.line] {
                    return false
                }
                return true
            }
            return (tokens, changes)
        }

        // Update tokens
        tokens = newTokens

        // Remove rules that should only be run once
        if iteration == 0 {
            rules = originalRules
            rules.removeAll(where: { $0.runOnceOnly })
        }
    }

    // If we got here, formatting failed to terminate within max iterations
    let rulesApplied = Set(lastChanges.map(\.rule.name)).sorted()
    guard !rulesApplied.isEmpty else {
        // No rules were applied this time (maybe something else went wrong?)
        throw FormatError.writing("Failed to terminate")
    }
    let names = rulesApplied.count == 1 ?
        "\(rulesApplied[0]) rule" :
        "\(rulesApplied.formattedList(lastSeparator: "and")) rules"
    let changeLines = Set(lastChanges.map { "\($0.line)" }).sorted()
    let lines = changeLines.count == 1 ?
        "line \(changeLines[0])" :
        "lines \(changeLines.formattedList(lastSeparator: "and"))"
    throw FormatError.writing("The \(names) failed to terminate at \(lines)")
}

/// Format a pre-parsed token array
/// Returns the formatted token array
public func format(
    _ tokens: [Token], rules: [FormatRule] = FormatRules.default,
    options: FormatOptions = .default, range: Range<Int>? = nil
) throws -> (tokens: [Token], changes: [Formatter.Change]) {
    try applyRules(rules, to: tokens, with: options, trackChanges: true, range: range)
}

/// Format code with specified rules and options
public func format(
    _ source: String, rules: [FormatRule] = FormatRules.default,
    options: FormatOptions = .default, lineRange: ClosedRange<Int>? = nil
) throws -> (output: String, changes: [Formatter.Change]) {
    let tokens = tokenize(source)
    let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) }
    let output = try format(tokens, rules: rules, options: options, range: range)
    return (sourceCode(for: output.tokens), output.changes)
}

/// Lint a pre-parsed token array
/// Returns the list of edits made
public func lint(
    _ tokens: [Token], rules: [FormatRule] = FormatRules.default,
    options: FormatOptions = .default, range: Range<Int>? = nil
) throws -> [Formatter.Change] {
    try applyRules(rules, to: tokens, with: options, trackChanges: true, range: range).changes
}

/// Lint code with specified rules and options
public func lint(
    _ source: String, rules: [FormatRule] = FormatRules.default,
    options: FormatOptions = .default, lineRange: ClosedRange<Int>? = nil
) throws -> [Formatter.Change] {
    let tokens = tokenize(source)
    let range = lineRange.map { tokenRange(forLineRange: $0, in: tokens) }
    return try lint(tokens, rules: rules, options: options, range: range)
}

// MARK: Path utilities

public func expandPath(_ path: String, in directory: String) -> URL {
    let nsPath: NSString = (path as NSString).expandingTildeInPath as NSString
    if nsPath.isAbsolutePath {
        return URL(fileURLWithPath: nsPath as String).standardized
    }
    return URL(fileURLWithPath: directory, isDirectory: true).appendingPathComponent(path).standardized
}

func getResourceValues(for url: URL, keys: [URLResourceKey]) throws -> URLResourceValues {
    if let resourceValues = try? url.resourceValues(forKeys: Set(keys)) {
        return resourceValues
    }
    if FileManager.default.fileExists(atPath: url.path) {
        throw FormatError.reading("Failed to read attributes for \(url.path)")
    }
    throw FormatError.options("File not found at \(url.path)")
}

// MARK: Documentation utilities

/// Strip markdown code-formatting
func stripMarkdown(_ input: String) -> String {
    var result = ""
    var startCount = 0
    var endCount = 0
    var escaped = false
    for c in input {
        if c == "`" {
            if escaped {
                endCount += 1
            } else {
                startCount += 1
            }
        } else {
            if escaped, endCount > 0 {
                if endCount != startCount {
                    result += String(repeating: "`", count: endCount)
                } else {
                    escaped = false
                    startCount = 0
                }
                endCount = 0
            }
            if startCount > 0 {
                escaped = true
            }
            result.append(c)
        }
    }
    return result
}
